iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0
AI & Data

零基礎 AI 入門!從 Wx+b 到熱門模型的完整之路!系列 第 22

【Day 22】不靠 Encoder?用 GPT-2 試試翻譯的可能性

  • 分享至 

  • xImage
  •  

前言

在進行中文翻英文的任務時,我們這次使用 GPT-2 進行訓練,並延續先前提到過的資料集與概念。回顧一下之前我們提過像是 [CLS] 和 [SEP] 這類特殊標籤在 BERT 類模型中的作用,但在 GPT-2 這類僅由 Decoder 組成的模型架構中,它的運作邏輯是不同的。

使用GPT-2進行中翻英

GPT-2 模型主要是依賴因果語言建模(Causal Language Modeling)來預測序列中的下一個詞,而不是整體句子的分類或雙句任務。因此在這種語言生成的場景中,我們會直接餵入原始的中文句子,讓模型去生成對應的英文翻譯。

1.準備資料集

我們首先同樣的透過 pandas 讀取 CSV 檔案並且使用 sklearn 的 train_test_split 將整體資料集以 8:2 的比例劃分為訓練與驗證集。

import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.read_csv('translate.csv')
input_texts = df['chinese'].values
target_texts = df['english'].values

x_train, x_val, y_train, y_val = train_test_split(input_texts, target_texts, train_size=0.8, random_state=46)

2. 讀取模型權重

接著到了模型載入的階段第一種較為直接我們可以透過 Hugging Face 的 transformers 套件,直接調用官方訓練好的 GPT-2 模型與對應的 tokenizer。這裡特別要注意的一點是,GPT-2 原生並未設定 padding token,因此我們手動將 pad_token 設定為 eos_token,這樣在批次處理時才不會出錯。

from transformers import AutoTokenizer, AutoModelForCausalLM

tokenizer = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer.pad_token = tokenizer.eos_token  # 避免 padding 出錯

另一種方式則是利用自己自定義的 GPT-2 模型結構來承接 Hugging Face 所提供的模型權重。這裡我們透過 GPT2LMHeadModel 載入 Hugging Face 的 GPT-2 權重,並取得其中的 config 配置資訊,再根據這份設定來初始化我們自己的 GPT-2 模型架構。

    from transformers import GPT2LMHeadModel as HFGPT2

    # Load HF model and config
    hf = HFGPT2.from_pretrained("gpt2")
    config = hf.config  # GPT2Config with fields: n_embd, n_head, n_layer, n_positions, layer_norm_epsilon, etc.

    # Build our model with the same config and load weights
    model = GPT2LMHeadModel(config)
    sd = hf.state_dict()
    missing, unexpected = model.load_state_dict(sd, strict=False)
    print("Missing keys:", missing)
    print("Unexpected keys:", unexpected)

    # Quick forward
    B, T = 2, 16
    input_ids = torch.randint(0, config.vocab_size, (B, T))
    labels = input_ids.clone()
    attn_mask = torch.ones(B, T, dtype=torch.long)  # keep all tokens

    out = model(
        input_ids=input_ids,
        attention_mask=attn_mask,
        labels=labels,
        output_hidden_states=True,
        return_dict=True,
    )
    print("Loss:", float(out["loss"]))
    print("Logits shape:", tuple(out["logits"].shape))  # [B, T, vocab]

最後我們也可以使用 state dict 讀入我們的模型中,並找出有遺漏或不匹配的鍵值名稱,以確認權重是否正確套用。同時為了保險起見,我們也跑了一次 forward pass,隨機產生兩筆長度為 16 的序列資料,讓模型輸出 loss 與 logits,藉此驗證模型的基本功能是否正常。

3.建立DataLoader

在GPT-2這類的預訓練模型中,通常會使用prompt進行訓練,因此我們可以在DataLoader抓取資料時自動套用一個特定的 prompt 模板,例如 "Translate Chinese to English: 你好 => Hello"。這種方式其實有點像是做「少量提示學習(few-shot prompting)」,利用 prompt 結構告訴模型它目前的任務是翻譯中文成英文。

接下來這個類別裡最核心的其實是 collate_fn 這個方法,我們把每一筆資料按照指定格式拼接成一段文字,然後一口氣送進 tokenizer。這裡做了一個重要的處理我們在每一段訓練輸入後面都加上了 tokenizer.eos_token,這是讓 GPT-2 知道句子結束的信號。

from torch.utils.data import Dataset, DataLoader

# ---------- 自訂 Dataset 類別 ----------
class GPT2TranslateDataset(Dataset):
    def __init__(self, sources, targets, tokenizer, prompt="Translate Chinese to English: {} =>"):
        self.sources = sources
        self.targets = targets
        self.tokenizer = tokenizer
        self.prompt = prompt

    def __len__(self):
        return len(self.sources)

    def __getitem__(self, idx):
        return self.sources[idx], self.targets[idx]

    def collate_fn(self, batch):
        sources, targets = zip(*batch)
        texts = [self.prompt.format(src) + tgt + self.tokenizer.eos_token for src, tgt in zip(sources, targets)]

        tokenized = self.tokenizer(texts, padding=True, truncation=True, max_length=512, return_tensors='pt')

        input_ids = tokenized['input_ids']
        attention_mask = tokenized['attention_mask']

        labels = input_ids.clone()
        labels[attention_mask == 0] = -100

        return {
            'input_ids': input_ids,
            'attention_mask': attention_mask,
            'labels': labels
        }
    
prompt_template="Translate Chinese to English: {} =>"
trainset = GPT2TranslateDataset(x_train, y_train, tokenizer, prompt_template)
validset = GPT2TranslateDataset(x_val, y_val, tokenizer, prompt_template)

train_loader = DataLoader(
    trainset,
    batch_size = 16,
    shuffle = True,
    num_workers = 0,
    pin_memory = True,
    collate_fn = trainset.collate_fn
)

valid_loader = DataLoader(
    validset,
    batch_size = 16,
    shuffle = False,
    num_workers = 0,
    pin_memory = True,
    collate_fn = validset.collate_fn
)

而在這裡我們將padding 的部分在 labels 裡標記為 -100,這樣在計算 loss 的時候就會自動忽略這些 token,避免干擾模型的訓練(Pytorch的損失函數預設-100是不被計算的)

4.訓練模型

接下來訓練的時候我們會加上一個叫 get_cosine_schedule_with_warmup 的方法,簡單來說就是一種把學習率先拉高再慢慢降下來的策略。它一開始會用 warmup 的方式讓學習率慢慢升高,接著再按照餘弦曲線慢慢往下調,這樣可以幫助模型在訓練初期更穩定,不會一開始就學太快、搞得很不穩。

這邊我們把 warmup 的步數設成整個訓練步數的 20%,也就是說前 20% 的時間學習率會漸漸升上去,之後再緩緩下降。

from trainer import Trainer
import torch.optim as optim
from transformers import get_cosine_schedule_with_warmup

# 總步數 = epoch 數 * 每個 epoch 的 batch 數
num_training_steps = len(train_loader) * 100  # 100 是總 epoch 數
num_warmup_steps = int(0.2 * len(train_loader))  # 可調整 warmup 比例

optimizer = optim.AdamW(model.parameters(), lr=1e-3)
scheduler = get_cosine_schedule_with_warmup(
    optimizer,
    num_warmup_steps=num_warmup_steps,
    num_training_steps=num_training_steps,
)
trainer = Trainer(
    epochs=100,
    train_loader=train_loader,
    valid_loader=valid_loader,
    model=model,
    optimizer=optimizer,
    scheduler=scheduler,
    early_stopping=2,
    load_best_model=True,
    grad_clip=1.0,
)

trainer.train(show_loss=True)

輸出結果:

Train Epoch 1: 100%|██████████| 1496/1496 [01:23<00:00, 17.83it/s, loss=1.221]
Valid Epoch 1: 100%|██████████| 374/374 [00:07<00:00, 48.00it/s, loss=1.512]
Saving Model With Loss 1.42683
Train Loss: 1.39996 | Valid Loss: 1.42683 | Best Loss: 1.42683

其實這種模型不用花太多時間訓練,因為我們大多只會調最後那層 head 的權重,讓它更貼近我們要解決的問題。

5.模型評估

當模型需要把輸入補到一樣長的時候,通常會選擇把 padding token 加在「左邊」,這在做生成任務的時候特別重要,尤其是像 GPT 這種自回歸模型。因為這類模型是從左到右一個字一個字慢慢生成的。如果 padding 加在右邊,而又沒給 attention mask,那模型一開始就會看到一堆沒用的 padding,結果可能會亂生成。

而且左側 padding 還有個實務上的好處——它讓計算更有效率。舉例來說,batch 處理時模型會用 attention mask 去跳過 padding 的部分。如果所有 padding 都在左邊,那有效的文字內容就會整齊地對齊在右邊,這樣在做矩陣運算的時候資料會比較緊湊,對像 GPU 這種硬體來說也比較好發揮,速度會更快。

import torch
import sacrebleu

def translate_and_eval(model, tokenizer, loader):
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model.to(device).eval()
    hyps, refs = [], []
    with torch.no_grad():
        for batch in loader:
            batch = {k:v.to(device) for k,v in batch.items()}
            out = model.generate(**{k: v for k,v in batch.items() if k != 'labels'})
            
            # decode hypotheses
            hyps += tokenizer.batch_decode(out, skip_special_tokens=True)

            # handle -100 in labels
            labels = batch['labels'].clone()
            labels[labels == -100] = tokenizer.pad_token_id
            refs += tokenizer.batch_decode(labels, skip_special_tokens=True)

    bleu = sacrebleu.corpus_bleu(hyps, [refs], lowercase=True)
    print(f"Corpus BLEU: {bleu.score:.2f}")

# 呼叫
tokenizer.padding_side = "left"
tokenizer.pad_token = tokenizer.eos_token
translate_and_eval(model, tokenizer, valid_loader)

輸出結果:

Corpus BLEU: 28.46%

可以看到我們最終的結果,雖然跟傳統的 seq2seq 模型差不多,但在訓練速度和評估效率上還是有蠻明顯的差別。至於為什麼效能沒有差太多,主要是因為 Decoder 模型不像 Encoder 那樣擅長「理解」語言的結構和語意,所以兩者在效果上不會差太遠。

下集預告

現在我們已經學過了 Encoder 和 Decoder,那接下來就來看看把這兩個結合起來的 Encoder-Decoder 架構吧!順帶一提,明天我們也會進入一個新主題,第一次接觸語音模型,不過你已經理解的Transformer所以我相信你很快就能知道這些模型在幹嘛了。


上一篇
【Day 21】從 Wx+b 到能寫詩的模型GPT-2 的煉成
下一篇
【Day 23】語音模型原來長這樣?Wx+b拆給你看Whisper 架構!
系列文
零基礎 AI 入門!從 Wx+b 到熱門模型的完整之路!24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言